Skip to content

Conversation

infiton
Copy link

@infiton infiton commented Aug 13, 2025

Description

This PR fixes an inconsistency in how browserHash values are set in depsOptimizer.metadata during the initial dependency scan, which can cause duplicate module instances to be loaded in the browser when the Vite dev server restarts mid–asset waterfall.

Background

At Gadget, we use Vite both for development (serving a React frontend) and for production builds. In development, each user works in an ephemeral environment (“sandbox”), which runs both:

  • A Vite dev server serving the frontend.
  • A Fastify backend server (also hosting the Vite dev server).

For frontend-only changes, the Vite server stays running and uses HMR to push updates to the browser.
For backend changes, however, we restart the entire Fastify process — which also restarts the Vite dev server.

These backend restarts are cheap and frequent, so developers often trigger them while the frontend is still loading in the browser.

The Problem

When a restart happens during the initial page load, we can hit the following sequence:

  1. Cold boot: a new sandbox starts, and Vite builds dependency metadata (depsOptimizer._metadata.json) from scratch.
  2. The first app request is served using this fresh metadata, and the browser begins fetching the asset waterfall (JS chunks, CSS, etc.).
  3. Mid-load restart: the sandbox restarts while the browser is still fetching assets.
  4. A new Vite server starts and loads the existing _metadata.json from disk (written in step 1).
  5. Remaining asset requests in the waterfall are served from this second server.

Because browserHash in the in-memory metadata for the first server was not the same value eventually persisted to _metadata.json, the same dependency can be served with two different browser hashes across the restart.

If the dependency is React (or any library that maintains singleton state), this results in the browser having duplicate copies of the module, causing broken HMR, hydration errors, and subtle runtime issues.

Solution

This patch ensures that before resolving scanProcessing in the optimizer, we eagerly set the browserHash on the in-memory metadata to the value that will later be written to disk.

By doing this, both:

  • The first server (before writing to disk)
  • Any subsequent server (loading from _metadata.json)

will use identical browser hashes for the same dependency, as long as we wait for the initial scan to complete before resolving dependencies.

Why This Matters

  • Fixes a correctness issue that can manifest in real-world setups where Vite dev servers restart mid-load.
  • Ensures consistency between the in-memory and persisted metadata for dependencies.
  • Prevents hard-to-debug runtime errors caused by multiple versions of a singleton dependency being loaded into the same page.

@infiton
Copy link
Author

infiton commented Aug 18, 2025

cc @bluwy is there anything I need to do to get this into triage?

@airhorns

This comment was marked as duplicate.

@aurelienbobenrieth

This comment was marked as duplicate.

@seilon

This comment was marked as duplicate.

@sapphi-red
Copy link
Member

Hmm, I think Vite expects all assets used in the same page to be loaded from the same Vite server instance (i.e. Vite server is not restarted during loading a page and the assets).

  1. Mid-load restart: the sandbox restarts while the browser is still fetching assets.
  2. A new Vite server starts and loads the existing _metadata.json from disk (written in step 1).
  3. Remaining asset requests in the waterfall are served from this second server.

Wouldn't this cause the WebSocket connection to disconnect and the client to call location.reload()?

@sapphi-red sapphi-red added the feat: deps optimizer Esbuild Dependencies Optimization label Aug 21, 2025
@infiton
Copy link
Author

infiton commented Aug 21, 2025

Vite expects all assets used in the same page to be loaded from the same Vite server instance

it makes sense that this would be an expectation, and I think there's a unique setup in Gadget that is exposing this issue where others probably haven't seen this ever

Wouldn't this cause the WebSocket connection to disconnect and the client to call location.reload()?

not necessarily; we see this happen the most frequently in react router SSR mode where the initial document is served and it loads some big modules like react and eventually it's hmr runtime which then loads the vite client (which then attempts to set up an hmr connection); if the server restarts while the hmr socket is trying to connect it can reject with:

Uncaught (in promise) Error: WebSocket closed without opened.
    at WebSocket.<anonymous> (client:454:22)

in ordinary situations the vite client will start polling for a reconnect, however in the meantime a duplicate copy of react loads and the page load crashes with

chunk-HUR6ZVB7.js?v=c5431c96:9255 TypeError: Cannot read properties of null (reading 'useContext')
    at Object.useContext (chunk-4S2OYCSJ.js?v=d029a2ac:1062:29)
    at useParams (chunk-HUR6ZVB7.js?v=d029a2ac:5464:28)
    at useErrorBoundaryProps (chunk-HUR6ZVB7.js?v=d029a2ac:6547:13)
    at WithErrorBoundaryProps2 (chunk-HUR6ZVB7.js?v=d029a2ac:6561:19)
    at renderWithHooks (chunk-ZSAHMUTT.js?v=c5431c96:11596:26)
    at mountIndeterminateComponent (chunk-ZSAHMUTT.js?v=c5431c96:14974:21)
    at beginWork (chunk-ZSAHMUTT.js?v=c5431c96:15962:22)
    at beginWork$1 (chunk-ZSAHMUTT.js?v=c5431c96:19806:22)
    at performUnitOfWork (chunk-ZSAHMUTT.js?v=c5431c96:19251:20)
    at workLoopSync (chunk-ZSAHMUTT.js?v=c5431c96:19190:13)

In general do you agree that when vite first builds the optimized deps that the browser hash before and after restart should be consistent? It feels wrong that the browser hash in memory after the optimizer is finished doesn't match what it just wrote to disk

@colby-makowsky

This comment was marked as duplicate.

@sapphi-red
Copy link
Member

In general do you agree that when vite first builds the optimized deps that the browser hash before and after restart should be consistent? It feels wrong that the browser hash in memory after the optimizer is finished doesn't match what it just wrote to disk

I think it is better to be like that, but not necessarily have to be. So I'm not opposed unless it breaks other behaviors.

Wouldn't this cause the WebSocket connection to disconnect and the client to call location.reload()?

not necessarily; we see this happen the most frequently in react router SSR mode where the initial document is served and it loads some big modules like react and eventually it's hmr runtime which then loads the vite client

Hmm, that could happen. That said, unless there's a reason, I'll recommend loading the client before any other scripts so that the file changes during the initial load is sent to the browser properly.

@infiton
Copy link
Author

infiton commented Aug 22, 2025

That said, unless there's a reason, I'll recommend loading the client before any other scripts so that the file changes during the initial load is sent to the browser properly.

yah that makes sense, unfortunately in our case we don't control the load order as it is determined by (1) the frontend framework (react-router v7 in this case) and then (2) our users that can write arbitrary code and our goal is to make the underlying tech as reliable as possible

@infiton
Copy link
Author

infiton commented Aug 22, 2025

I'm going on vacation for a week, so tagging in my colleague @airhorns to address any comments while I'm away

@airhorns
Copy link
Contributor

Just following up here -- happy to make any changes necessary or address comments

Hmm, that could happen. That said, unless there's a reason, I'll recommend loading the client before any other scripts so that the file changes during the initial load is sent to the browser properly.

For this, you are right, but I think even if the vite client is the first script loaded on the page, we still can't guarantee that the vite server didn't reboot in between when the page was served and then the client is requested. I think in normal local operation it'd be incredibly unlucky, but in remote development contexts like Github codespaces / codesandbox / Gadget etc, I think the window of opportunity gets bigger.

I am not incredibly familiar with Vite's internals, but AFAICT this shouldn't have much of a negative performance impact, and corrects the bug for these users, so is there a downside to including this fix?

@sapphi-red sapphi-red added the p2-edge-case Bug, but has workaround or limited in scope (priority) label Sep 1, 2025
@infiton
Copy link
Author

infiton commented Sep 3, 2025

back from vacation now so I can respond to any feedback

@infiton
Copy link
Author

infiton commented Sep 8, 2025

ping @sapphi-red think we are ok to merge this?

@sapphi-red sapphi-red changed the title fix(optimize-deps): ensure consistent browserHash between in-memory a… fix(optimizer): ensure consistent browserHash between in-memory and persisted metadata Sep 13, 2025
@sapphi-red
Copy link
Member

I'd like another member to take a look.

Copy link
Member

@bluwy bluwy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not very familiar with the optimizer flow, but this seem fine to me.

For the new await depsOptimizer.scanProcessing additions, can we add a comment about explaining why we're waiting for processing? I think it's to await for a stable browserHash as it could change?

@infiton
Copy link
Author

infiton commented Sep 17, 2025

@sapphi-red you feel comfortable to merge?

@bluwy
Copy link
Member

bluwy commented Sep 17, 2025

My comment hasn't been addressed yet. And I'm sure sapphi will merge as soon as he feels ready, there's no need to push as the PR isn't stale yet.

@hi-ogawa
Copy link
Contributor

/ecosystem-ci run

Copy link

pkg-pr-new bot commented Sep 18, 2025

Open in StackBlitz

npm i https://pkg.pr.new/vite@20609

commit: a1d485c

@vite-ecosystem-ci

This comment was marked as duplicate.

@vite-ecosystem-ci
Copy link

@sapphi-red
Copy link
Member

sapphi-red commented Sep 18, 2025

Hmm, it seems this change breaks sveltekit's test. That has to be checked before merging as well.
sveltekit failed with main branch as well, so the failure is not related to this PR.
https://github.com/vitejs/vite-ecosystem-ci/actions/runs/17817427184/job/50653363205

hi-ogawa
hi-ogawa previously approved these changes Sep 18, 2025
Copy link
Contributor

@hi-ogawa hi-ogawa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also not sure this will the complete fix for the explained use case, but this change looks okay on its own and as a start.

By searching ?v= injection, I also saw this line for example. Is this code path not relevant or should also await scanProcessing or is it already guaranteed somehow?

const versionHash = depsOptimizer.metadata.browserHash
if (versionHash && isJsType) {
resolved = injectQuery(resolved, `v=${versionHash}`)

@infiton
Copy link
Author

infiton commented Sep 23, 2025

hey folks, is there any thing I can do to help get this released?

@hi-ogawa
Copy link
Contributor

Btw, have you tested this patch on your platform? or does it require this to be published on npm? FYI, as a preview release, the package made from this PR can be installed like #20609 (comment)

@infiton
Copy link
Author

infiton commented Sep 24, 2025

Btw, have you tested this patch on your platform? or does it require this to be published on npm? FYI, as a preview release, the package made from this PR can be installed like #20609 (comment)

yah we've been running with this patch on top of version of 6.3.5 for over a month now and it's completely resolved our issue

Copy link
Member

@sapphi-red sapphi-red left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these two needs to be addressed.

For the new await depsOptimizer.scanProcessing additions, can we add a comment about explaining why we're waiting for processing? I think it's to await for a stable browserHash as it could change?
#20609 (review)

By searching ?v= injection, I also saw this line for example. Is this code path not relevant or should also await scanProcessing or is it already guaranteed somehow?

const versionHash = depsOptimizer.metadata.browserHash
if (versionHash && isJsType) {
resolved = injectQuery(resolved, `v=${versionHash}`)

#20609 (review)

@infiton
Copy link
Author

infiton commented Oct 9, 2025

ok @sapphi-red I believe I have addressed those comments now

@infiton infiton force-pushed the optimize-deps-browser-hash branch from 1e2b8af to 4b3f01e Compare October 9, 2025 23:28
…nd persisted metadata

When the dev server restarts mid–asset waterfall and loads cached
`_metadata.json`, inconsistent `browserHash` values can cause duplicate
instances of the same dependency to be loaded (e.g. multiple React copies).

This patch sets the `browserHash` eagerly in memory before resolving
`scanProcessing`, ensuring both the in-memory metadata and the persisted
metadata use the same value.
@infiton infiton force-pushed the optimize-deps-browser-hash branch from 4b3f01e to a1d485c Compare October 9, 2025 23:45
@sapphi-red
Copy link
Member

Thanks for the update.

While re-reviewing the PR, I noticed that this fix would have other problems.

If the WebSocket is not connected properly as mentioned in #20609 (comment), Vite won't work properly anyway. I think it's better to show the websocket connection error in the error overlay so that users can notice it instead of this change.

Even if the WebSocket is connected properly due to the client load is done after other scripts (#20609 (comment)), the module graph would be partial and that could cause HMR issues. We need to trigger a reload if the whole page is not served by a single server instance, but I'm not sure if that's possible to do on Vite side.

@patak-dev pointed out that this change essentially makes the scanner blocking, which would revert #7379. If we decide to go with that behavior change, I think it would be better to await the scanner instead of applying the change in this PR.

In addition, the diff ended up being larger than I initially expected, so it's a bit difficult for me to merge this PR into the rolldown-vite side and I prefer to wait until the next major.

Sorry for going back and forth on this.

@hi-ogawa
Copy link
Contributor

hi-ogawa commented Oct 17, 2025

I recently came across similar errors while working on SSR demo for #20913. I think this is a minimal repro https://github.com/hi-ogawa/reproductions/tree/main/vite-20609-mismatch-but-no-reload to show what I saw. Note that my repro doesn't have anything like "Mid-load restart" and the cause seems clear that it's because @vite/client is loaded too late and connection is not yet available when Vite server sends full reload event.

I wonder if Gadget's scenario is actually not because of their specific setup but it's simply because of @vite/client being loaded too late in React router framework mode. If that's the cause, I think ensuring import "@vite/client" at the top of client entry can help. I think it would be nice to have the concrete reproduction of your setup to understand the situation better. (Also optimizeDeps of SSR framework is a tricky topic too by itself.)

@infiton
Copy link
Author

infiton commented Oct 17, 2025

@hi-ogawa thanks for the repro I need to see if it’s the exact situation that gadget has, but in your repro are you saying there is a userland fix? Do you mind saying more about how you would do that?

@hi-ogawa
Copy link
Contributor

hi-ogawa commented Oct 17, 2025

Hmm, actually if you are not seeing "optimized dependencies changed. reloading", the cause of the issue must be different. It's only shows why @vite/client being loaded first might be important for a different reason.

For react router's case, I'm not sure what you would call "user land" solution, but for example, adding <script type="module" src="/@vite/client" async></script> in html head is one way. Another way might be to add import "/@vite/client" to the top of React router's inline bootstrap script which is inside <Scripts /> component https://github.com/remix-run/react-router/blob/64f82f1581bd5e72a9312802c60bb61b9278135a/packages/react-router/lib/dom/ssr/components.tsx#L729

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat: deps optimizer Esbuild Dependencies Optimization p2-edge-case Bug, but has workaround or limited in scope (priority) trigger: preview

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants